# 问题

之所以会有这个问题,是因为在做静态网站的时候,SEO 人员发现文本前后空格的问题,并提出文本前后不要有前后空格的需求。

用 SPA 做测试,Vue 在解析 template 中标签文本会有以下两种情况:

  • 标签内换行
<div>
  文本
</div>

在浏览器渲染为:

标签内换行

  • 标签内不换行
<div>文本</div>

在浏览器渲染为:

标签内不换行

之前也做过 SSR 的测试。发现该问题不仅仅在 SSR,SPA 也存在该问题。

对问题定位:

在解析 vue 单文件 template 模版的时候对换行做了特殊处理,也就是 vue-loader (opens new window) 这个 loader。

如果不是通过 .vue 文件渲染页面,而是通过 template 模版字符串渲染,则不会 vue-loader 转化逻辑,而是通过 vue 内部的 compile 和 compileToFunctions 进行转化。主要代码如下:

function createCompilerCreator(baseCompile) {
  // ...
}

var createCompiler = createCompilerCreator(function baseCompile (
  template,
  options
) {
  // ...
})

var ref$1 = createCompiler(baseOptions);
var compile = ref$1.compile;
var compileToFunctions = ref$1.compileToFunctions;

不管是通过 vue-loader 还是内部编译逻辑,其核心都是使用 vue-template-compiler (opens new window) 这个插件。

# vue-template-compiler (opens new window)

查看文档,发现有 vue-template-compiler 有一个 whitespace 配置项:

  • preserve:默认项。
    • 元素标签之间的纯空白文本节点被压缩为一个空格。
    • 所有其他空格按原样保留。
  • condense
    • 如果元素标签之间的纯空白文本节点包含新行,则会被删除。否则,它会被压缩为一个空格。
    • 非纯空格文本节点内的连续空格被压缩为一个空格。

在 vue 项目中执行 vue inspect > output.js 查看 webpack 配置,找到 vue-loader 的配置,有如下配置:

{
    options: {
      compilerOptions: {
        whitespace: 'condense'
      }
    }
}

发现 vue-loader 的配置选项中的 compilerOptions 的 whitespace 设置为 'condense'。查看 vue-loader 文档链接 (opens new window),有 compilerOptions (opens new window) 配置说明,该配置项最终的配置就是 vue-template-compler 的 whitespace 配置。

vue-source-02

# 查看 vue-template-compiler 源码

# compile 函数

文件vue-template-compiler/build.js,使用的 compile 方法定义的主要代码为:

function createCompilerCreator (baseCompile) {
  return function createCompiler (baseOptions) {
    function compile (
      template,
      options
    ) {
        // ...
        var compiled = baseCompile(template.trim(), finalOptions);
        if (process.env.NODE_ENV !== 'production') {
          detectErrors(compiled.ast, warn);
        }
        compiled.errors = errors;
        compiled.tips = tips;
        return compiled
      }
    
    return {
      compile: compile
    }
  }
}

var createCompiler = createCompilerCreator(function baseCompile (
  template,
  options
) {
  var ast = parse(template.trim(), options);
  if (options.optimize !== false) {
    optimize(ast, options);
  }
  var code = generate(ast, options);
  return {
    ast: ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
});

var ref = createCompiler(baseOptions);
var compile = ref.compile;

exports.compile = compile;

compile 函数的定义:通过 createCompilerCreator 高阶函数返回的函数获取 createCompiler,再通过函数 createCompiler 返回的对象获取 compile 函数。

compile 函数的执行:实际上就是执行createCompilerCreator -> createCompiler -> compile -> baseCompile,compile 方法内部主要执行传入的 baseCompile 函数。

梳理下来,compile 函数主要执行逻辑的就是传入 createCompilerCreator 函数的 baseCompile 实参.

而 baseCompile 函数内部执行解析逻辑的是 parse 函数,流程跟常规的编译器基本一样,分为三个:

  • parse 解析
  • optimize 优化
  • generate 生成

# parse 函数

主要作用是将代码解析成 AST。

parse 函数的参数有两个:

  • template 模板字符串
  • option 配置项

函数内部先处理 options 并定义了一系列工具函数,然后调用 parseHTML 解析 template,最后返回根节点 root。

主要源码如下:

function parse (
  template,
  options
) {
    // 处理 options
    // ...
    // 定义工具函数
    // ...
    // 调用 parseHTML 函数
    parseHTML(template, { 
      // options
      start: function start (tag, attrs, unary, start$1, end) {
        // ...
      },
      end: function end (tag, start, end$1) {
        // ...
      },
      chars: function chars (text, start, end) {
        // ...
      },
      comment: function comment (text, start, end) {
        // ...
      }
    })
    return root
  }

传递给 parseHTML 的参数除了 template 模版和 options 选项外,还有四个函数:

  • start: 处理开始标签。主要逻辑为:为当前的标签创建 AST 对象,将当前 AST 对象入 stack 栈,最后将当前 AST 赋值给 currentParent 当作下一个标签的父节点。
  • end: 处理结束标签。判断当前标签是否为栈顶元素,并出栈。
  • chars: 处理文本字符串。上面提及的 whitespace 配置项就是在这里面处理的。
  • comment: 处理注释。

# parseHTML 函数

这里根据下面的 html 字符串整理流程:

<div>
  <span>
  	文本
  </span>
</div>

开始循环遍历 html 字符串,直到 html 全部遍历完。

 while (html) {
   // ...
 }

第一次循环。

匹配标签开始标志<,根据当前匹配到的索引 textEnd 判断标签当前文本类型:

  • textEnd 为 0:当前 html 以标签开始。
  • textEnd 大于 0:当前 html 以文本开始。
  • textEnd 小于 0:html 遍历结束。
while (html) {
  // ...
  var textEnd = html.indexOf('<');
  if (textEnd === 0) {
    // ...
  }
  if (textEnd >= 0) {
    // ...
  }
  if (textEnd < 0) {
    // ...
  }
  // ...
}

回到例子,html 匹配标签开始标志<,textEnd 为 0,说明当前 html 是以标签开始。

接着进入 parseStartTag 处理函数,该函数主要是获取标签名,还有标签在当前 html 中的开始索引 start 和结束索引 end。{ tagName: 'div', attrs: [], start: 0, unarySlash: '', end: 5 }

然后通过 advance 函数将 html 剔除开始标签字符串。

function advance (n) {
  index += n;
  html = html.substring(n);
}

最后执行通过参数传入 parseHTML 函数的 options.start,该函数会根据当前标签创建 AST 元素对象,并将当前 AST 对象的 parent 设置为父节点 currentParent,最后将 AST 对象入栈 stack,并将当前 AST 赋值给 currentParent 当作下一个标签的父节点。

由于第一次循环,root 为空,会将当前 AST 元素对象赋值给 root 变量。

parstHTML(template, {
  start: function start(tag, attrs, unary, start$1, end) {
    // ...
    var element = createASTElement(tag, attrs, currentParent);
    // ...
    if (!root) {
      root = element;
      if (process.env.NODE_ENV !== 'production') {
        checkRootConstraints(root);
      }
    }

    // ...
    if (!unary) {
      currentParent = element;
      stack.push(element);
    } else {
      closeElement(element);
    }
  }
})

第一次循环结束,会得到:

下次循环的 html:


  <span>
  	文本
  </span>
</div>

stack:

[{
  "type": 1,
  "tag": "div",
  "attrsList": [],
  "attrsMap": {},
  "rawAttrsMap": {},
  "children": []
}]

第二次循环。

剩下的 html 匹配标签开始标志<,textEnd 大于 0,说明当前 html 到下一个标签之前存在文本。

接着通过执行通过text=html.substring(0, textEnd)获取文本内容。

然后通过 advance 函数将 html 剔除开始标签字符串。

最后执行通过参数传入 parseHTML 函数的 options.chars,该函数会根据不同配置项对文本格式做不同的处理。

如果 text 文本做trim()处理之后为空,并且父节点 currentParent 的 children 为空,说明当前 text 文本为父节点和第一个字节点之前的标签内容,则直接将 text 设置为空,直接返回。

parstHTML(template, {
  chars: function chars(text, start, end) {
    // ...
    if (inPre || text.trim()) {
      text = isTextTag(currentParent) ? text : decodeHTMLCached(text);
    } else if (!children.length) {
      // remove the whitespace-only node right after an opening tag
      text = '';
    } else if (whitespaceOption) {
      if (whitespaceOption === 'condense') {
        // in condense mode, remove the whitespace node if it contains
        // line break, otherwise condense to a single space
        text = lineBreakRE.test(text) ? '' : ' ';
      } else {
        text = ' ';
      }
    } else {
      text = preserveWhitespace ? ' ' : '';
    }
  }
})

如果 text 文本做trim()处理之后不为空,则将处理好的 text 文本内容设置为父节点 currentParent 的子节点。

  • preserve:默认项。

    • 元素标签之间的纯空白文本节点被压缩为一个空格。
    • 所有其他空格按原样保留。
  • condense

    • 如果元素标签之间的纯空白文本节点包含新行,则会被删除。否则,它会被压缩为一个空格。
    • 非纯空格文本节点内的连续空格被压缩为一个空格。
    if (!inPre && whitespaceOption === 'condense') {
      // condense consecutive whitespaces into single space
      text = text.replace(whitespaceRE, ' ');
    }
    

回到例子,由于当前获取的文本 text 做trim()处理之后为空,并且父节点 currentParent 的 children 为空,会直接将 text 设置为空,并结束当前循环。

第二次循环结束,会得到:

下次循环的 html:

<span>
  	文本
  </span>
</div>

stack 内容不变。

第三次循环。

剩下的 html 匹配标签开始标志<,textEnd 为 0,说明当前 html 是以标签开始。

接着进入 parseStartTag 处理函数,该函数主要是获取标签名,还有标签在当前 html 中的开始索引 start 和结束索引 end。{ tagName: 'span', attrs: [], start: 8, unarySlash: '', end: 14 }

然后通过 advance 函数将 html 剔除开始标签字符串。

最后执行通过参数传入 parseHTML 函数的 options.start,该函数会根据当前标签创建 AST 元素对象,当后将 AST 对象入栈 stack,并将当前 AST 赋值给 currentParent 当作下一个标签的父节点。

第三次循环结束,会得到:

下次循环的 html:


  	文本
  </span>
</div>

stack:

[
  {
    "type": 1,
    "tag": "div",
    "attrsList": [],
    "attrsMap": {},
    "rawAttrsMap": {},
    "children": []
  },
  {
    "type": 1,
    "tag": "span",
    "attrsList": [],
    "attrsMap": {},
    "rawAttrsMap": {},
    "parent": {
      "type": 1,
      "tag": "div",
      "attrsList": [],
      "attrsMap": {},
      "rawAttrsMap": {},
      "children": []
    },
    "children": []
  }
]

第四次循环。

剩下的 html 匹配标签开始标志<,textEnd 大于 0,说明当前 html 到下一个标签之前存在文本。

接着通过执行通过text=html.substring(0, textEnd)获取文本内容。

然后通过 advance 函数将 html 剔除开始标签字符串。

由于 text 文本做trim()处理之后不为空,则将处理好的 text 文本内容设置为父节点 currentParent 的子节点。

第四次循环结束,会得到:

下次循环的 html:

</span>
</div>

stack:

[
  {
    "type": 1,
    "tag": "div",
    "attrsList": [],
    "attrsMap": {},
    "rawAttrsMap": {},
    "children": []
  },
  {
    "type": 1,
    "tag": "span",
    "attrsList": [],
    "attrsMap": {},
    "rawAttrsMap": {},
    "parent": {
      "type": 1,
      "tag": "div",
      "attrsList": [],
      "attrsMap": {},
      "rawAttrsMap": {},
      "children": []
    },
    "children": [
      {
        "type": 3,
        "text": " 文本 "
      }
    ]
  }
]

第五次循环。

剩下 html 匹配标签开始标志<,textEnd 为 0,说明当前 html 是以标签开始。

接着在判断是结束标签,则通过 advance 函数将 html 剔除开始标签字符串。

然后进入 parseEndTag 函数,调用通过参数传入 parseHTML 函数的 options.end,将当前标签弹出栈 stack。

第五次循环结束,会得到:

下次循环的 html:

</div>

stack:

[
  {
    "type": 1,
    "tag": "div",
    "attrsList": [],
    "attrsMap": {},
    "rawAttrsMap": {},
    "children": []
  }
]

第六次循环

跟第五次一样。最后 html 遍历结束,stack 也为空了。

parseHTML 执行结束,返回 root 根对象。